今天的主題是要加入項目的編輯功能,以及更多的CSS樣式。至於重點部份有兩個,第一個是CSS樣式的加入,第二個是如何切換項目的編輯。
這個程式最後的呈現結果,會是在網頁上出現一個文字輸入框,當你輸入文字後按下Enter鍵,就會把文字加到下面的列表中,在項目中改用勾選盒來更改狀態,用滑鼠左鍵雙點按(DoubleClick)可以進行項目的再編輯。就像下面的動態圖片這樣:
註: 本文章同步放置於Github庫的這裡,所有的程式碼也在裡面。
當程式愈來愈複雜的時候,實作經驗會愈來愈重要,學到了很多基礎知識雖然重要,但更重要的是實作經驗,以及解決問題的能力。有時候,一個小問題就有可能困住很久才能解決,這個範例程式愈來愈複雜,代表它裡面用到的功能愈來愈多,你可能要仔細地看,因為很多部份都是彼此關聯的,牽一髮而動全身。
首先,因為我們要能讓TodoItem可以在被新增後,還能進行重新的編輯,而且位置就在那個TodoItem上,這種功能要怎麼作?
過去使用jQuery或直接用JavaScript來寫,就是查詢到要編輯的那個DOM元素實體,然後在該節點替換成可輸入的文字輸入框,很直覺地作法,但現在這支程式並不是這樣作的,雖然也有替換的過程,但它有幾個關鍵步驟,建議你看過之後思考一下。
現在用在React上也是類似的,不過因為使用的是元件的概念,所以你需要用下面的步驟來思考:
state
(狀態)改變,用了setState
方法才會觸發重新渲染,所以上面觸發的事件一定是要改變到state值整個流程就是這樣。直接看程式碼會比較快。
首先我們新增了一個叫TodoEditForm
的新元件,它就是在雙點按(DoubleClick)某個項目時,要用來編輯這個項目上的標題文字用的。它有點像是TodoItem
與TodoAddForm
元件的組合。這也是個無狀態的元件,也就是用函式的語法來寫的元件。
函式的傳入參數類型如下,我們只需要要進行編輯的標題值,以及一個用於更新最後新的值的函式即可,這個onItemUpdate
函式,依然是來自上層元件(App)的指定值:
export type TodoEditFormProps = {
title: string,
onItemUpdate: (x: string) => void,
}
在TodoEditForm
元件中的回傳值部份,對文字輸入框用了兩個事件,一個是onBlur
,另一個是onKeyPress
,而且一開始就有一個autoFocus
的屬性,之前已經有說過了JSX在指定props屬性時,不指定任何值時相當於預設值true,下面是這個文字輸入框的程式碼:
<input
type="text"
defaultValue={title}
ref={el => { titleField = el }}
autoFocus
onBlur={(e) => {
if (titleField.value.trim()
&& e.target instanceof HTMLInputElement) {
//更新某個索引值的標題
onItemUpdate(titleField.value)
}
}
}
onKeyPress={(e) => {
if (titleField.value.trim()
&& e.target instanceof HTMLInputElement
&& e.key === 'Enter') {
//更新某個索引值的標題
onItemUpdate(titleField.value)
}
}
}
/>
這裡面還有一個要注意的地方,就是defaultValue
這個屬性,這屬性是React中人造的這種HTML的DOM元素才有的,它是用來指定給像這種文字輸入框值用的,等會還會看到另一個類似的屬性。你不能直接使用value
這個屬性,除非你有提供onChange
方法在這個元素上,不然React會把這個表單輸入元件當作是只能讀不能寫的,這是使用表單元素在這種無狀態函式中的一個要特別注意的地方。
你如果把這個defaultValue
改為value
,會出現下面的警告:
warning.js:36 Warning: Failed form propType: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`. Check the render method of `TodoEditForm`.
你可能會好奇,為何在更新某一筆項目資料時,不需要知道這筆資料的索引值?onItemUpdate
只有一個傳入參數,就是被改過的新字串而已。
因為就像之前在點按某個項目,然後改變這個項目的樣式的作法一樣,它在上層元件指定這個函式值的時候,就已經把索引值固定住了。下面是在App
元件中的TodoEditForm
元件的使用程式碼,有看到handleEditItemUpdate
其實需要兩個傳入參數,只是其中一個先給它了,後面那個title
再由TodoEditForm
中觸發事件時再給:
<TodoEditForm
key={item.id}
title={item.title}
onItemUpdate={(title) => { this.handleEditItemUpdate(index, title) }}
/>
好的,接著來看App
元件的程式碼變化,當然最主要的是像之前項目中的isCompleted
屬性,這次也新增了一個isEditing
,用於分辦哪一個項目正在進行編輯中,也是個布林值。所以新的Item類型的定義會像下面這樣:
export type Item = {
id: number,
title: string,
isCompleted: boolean,
isEditing: boolean,
}
雖然isEditing
有點像是個臨時才會用到的值,但這是因為整個運作流程,一定需要改變到state
值(用setState
方法),才能觸發重新渲染,這個機制你一定要記住,如果功能得要觸發重渲染,也就是畫面上要更動到DOM元素,只有這條路可走。
App
元件新增了兩個新的方法,一個是handleEditItem
,這是讓目前的TodoItem
被雙點按後,將被點按的那個項目的isEditing
屬性,從false
變為true
的一個方法。這個方法幾乎與之前的handleStylingItem
一樣,程式碼如下:
//處理其中一個陣列中成員變為編輯中的方法
handleEditItem = (index: number) => {
//拷貝一個新陣列
const newItems = [...this.state.items]
//切換isEditing的布林值
newItems[index].isEditing = !newItems[index].isEditing
//整個陣列重新更新
this.setState({
items: newItems,
})
}
另一個方法是要給TodoEditForm
編輯完成後儲存變動後的標題文字值用的,叫作handleEditItemUpdate
,它需要兩個傳入參數,一個是要變動的項目的索引值,另一個是要變成哪一個文字值,程式碼如下:
//處理其中一個陣列中成員編輯完後更新的方法
handleEditItemUpdate = (index: number, title: string) => {
//拷貝一個新陣列
const newItems = [...this.state.items]
//指定新的標題文字
newItems[index].title = title
//切換isEditing的布林值
newItems[index].isEditing = !newItems[index].isEditing
//整個陣列重新更新
this.setState({
items: newItems,
})
}
這兩個方法的程式碼都很簡單,沒什麼特別要說的,儲存完記得把isEditing
改回false
值就行了。
再來是因為Item的物件結構變了,所以原先的TodoAddForm也要變動一下,其一是在新增項目時,要多一個isEditing
值。其二是需要加上一個雙點按的事件。但還有一個最好要改的,因為原來我們是利用點按一下項目來變動項目的樣式,現在又有一個雙點按的事件,這兩個會影響到,所以把原先的點按事件,改用在一個勾選盒(checkbox)上。程式碼如下:
const TodoItem = ({ title, isCompleted, onItemClick, onItemDoubleClick }: TodoItemProps) => (
<li
onDoubleClick={onItemDoubleClick}
className={isCompleted
? 'list-group-item list-group-item-danger animated fadeIn'
: 'list-group-item list-group-item-success animated bounce'
}
>
<input
type="checkbox"
defaultChecked={isCompleted}
onClick={onItemClick}
/>
{' '}
{title}
</li>
)
這裡的defaultChecked
情況與上面說的defaultValue
類似,這也是React中的人造元素才有的屬性,相當於DOM元素上的checked
,但這裡沒用到onChange
事件,所以要改為defaultChecked
。
註: 程式碼中的className是套用了css樣式檔,等會再說明。
最後的關鍵在於App
的render
方法中的回傳值部份,在TodoList
標記之間的JSX表達式中,這裡本來是輸出TodoItem
元件的地方,但在這裡需要更動程式碼,如果項目中的isEditing
是true
值時,就改為使用TodoEditForm
元件,如果是false
才會用TodoItem
元件。為了更簡化語法,用了三元運算符(?:)來取代if...else
,程式碼如下:
<TodoList>
{
this.state.items.map((item, index) => (
(item.isEditing)
? <TodoEditForm
key={item.id}
title={item.title}
onItemUpdate={(title) => { this.handleEditItemUpdate(index, title) }}
/>
: <TodoItem
key={item.id}
isCompleted={item.isCompleted}
title={item.title}
onItemDoubleClick={() => { this.handleEditItem(index) }}
onItemClick={() => { this.handleStylingItem(index) }}
/>
)
)
}
</TodoList>
這邊的在props(屬性)指定值的三個方法,全部都先給定index值(項目在陣列中的索引值),寫法會有些特別要注意一下。
以上是有關於加入編輯項目文字框的說明。
你應該有看到,我加入了一些樣式在程式碼之中,目的讓原本看起來醜醜的TodoApp
美觀些。
這都是只使用CSS定義的部份而已,主要是用了Bootstrap與Animate.css。也有一些動畫的特效,不過都是純粹的CSS動畫而已。
Bootstrap的檔案是另外到它提供的線上自訂服務,去除掉與JavaScript有關的元件與Glyphicons。
要套用現成的CSS在React應用中,之前有說過如果Webpack有設定好,直接匯入到程式碼中就可以使用,你可以看到App.js的最上面有這幾句:
//匯入css檔
import '../style/bootstrap.css'
import '../style/animate.css'
這是在create-react-app所建立的專案中已經幫你都設定好了,而且只要最上層的App.js有匯入,裡面有用到的元件檔案就不需要再作匯入的動作,這一點我已經有試過,最後打包會自動幫你把所有的CSS檔合併壓縮,而且會產生Source Map(原始碼地圖),實在有夠方便。
這些樣式都是使用CSS類別名稱來指定就可以了,例如TodoList元件中的ul
標記,你可以看到像這樣套上CSS類別的樣式:
<ul className="list-group">{children}</ul>
所有的標記與元件我都加上了CSS的類別定義,在TodoItem元件中額外用了Animate的特效,它這裡面的特效是純粹的CSS特效,所以可以使用,不過檔案有點大,如果確定好要用什麼特效,應該只需要用到的部份就行了,Animate.css也有自訂編譯的方式。
所以你在網路上看到什麼好看的CSS樣式範例,直接拿來套用就行了,主要記得要把原來範例中的<div class="xxxx">
改為React中的<div className="xxxx">
。如果有JavaScript相關的特效記得不能用要先拿掉。
本章的目的是提供不同的思考方式,如果你已經是熟悉JavaScript或jQuery的開發者,這個功能絕對是難不倒你的。但換到React上來思考,就不是那樣的直覺式的查詢DOM,替換DOM元素節點的作法,你如果親自動手作看看,應該能體會其中的差異。這一個思考方向的不同,我認為在寫React應用時是很重要。
另一個重點是在於展示如何使用CSS樣式於React應用中,以單純的CSS類別套用來說,並沒有什麼不同,CSS特效同樣也可以套用。之前有說過React官方的文件是用內嵌樣式的語法,同樣你也可以直接用現成的CSS樣式。至於它們各自有什麼樣的一些細節,之前的章節內容中有稍微分析過了,你可以再仔細看看然後再決定你想要的作法,這個範例只是其中一種解決方案,不一定要這樣作。
最後因為文章篇幅的關係,可能無法介紹到React中的動畫要怎麼作,這其實也是另一個大議題,目前來說除了官方的內建附加套件外,文件說明在這裡,也有幾種解決的方式,有興趣的話你可以再研究看看。